<?php

/***  DOCUMENTATION LAYER

Klenwell HtmlValidator Class

Name: HtmlValidator
Last Update: Sep 2007
Author: Tom at klenwell@gmail.com

DESCRIPTION
  Validates HTML using PHP's XML validator object.  Based on a Simon Willison's
  script: http://simon.incutio.com/code/php/SafeHtmlChecker.class.php.txt

METHODS
  MAGIC
  HtmlValidator($debug=0, $oid=null)    *php 4 constructor*
  __construct($debug, $oid)             *php 5 constructor*
  __destruct()  
  
  PUBLIC
  validate($html)
  print_d($message, $color='c33')
  print_r()
  dump()
  
  PRIVATE
  _set_session_data()
  _get_session_data()
  _has_session_data()
  _set_filename()
  _set_dirpath()
  
USAGE
  $Validator = new HtmlValidator();
  $Validator->validate($html);
  if ( !$Validator->is_valid() ) print $Validator->get_prompt();

  
NOTES
  Actually an XHTML validator.

______________________________________________________________________________*/

// Load File of Base Class
$base_fname = 'class.abstract.php';
$base_dirpath = dirname(__FILE__) . DIRECTORY_SEPARATOR;
#require_once($base_dirpath . $base_fname);


// Class
/*____________________________________________________________________________*/
class HtmlValidator
{
/* PUBLIC PROPERTIES */
var $debug = 0;
var $class_name = __CLASS__;
var $oid = '';
var $DS = DIRECTORY_SEPARATOR;

// objects
var $XmlParser = 0;

// arrays
var $TAG_TREE = array();
var $WHITE = array();
var $ERROR = array();

// settings
var $container_tag = 'validation_block';
var $error_preamble = '';

/* PRIVATE PROPERTIES */
var $_STACK = array();
var $_filename = '';
var $_dirpath = '';


/* ** MAGIC METHODS ** */
// php4 constructor
function HtmlValidator($debug=0, $oid=null)
{
  $this->__construct($debug, $oid);
  register_shutdown_function( array($this, '__destruct') );
}
// END constructor

// php5 constructor
function __construct($debug=0, $oid=null)
{
  // default
  $this->debug = $debug;
  $this->oid = ( empty($oid) ) ? $this->class_name : $oid;
  $this->_set_filename();
  $this->_set_dirpath();
  
  // tag classes
  $this->TAG_CLASS['block'] = 'div dl ul ol blockquote p';
  $this->TAG_CLASS['inline'] = 'span b i em strong dfn code q samp kbd var cite abbr acronym sub sup a #PCDATA';
  $this->TAG_CLASS['flow'] = $this->TAG_CLASS['block'] . ' ' . $this->TAG_CLASS['inline'];
  
  // tag tree (what each tag may contain)
  $this->TAG_TREE = array
  (
    /* block */
    $this->container_tag => $this->TAG_CLASS['flow'],
    'div' => $this->TAG_CLASS['flow'],
    'p' => $this->TAG_CLASS['inline'],			
    'blockquote' => $this->TAG_CLASS['flow'],
    
    /* lists */
    'ul' => 'li',
    'ol' => 'li',
    'li' => $this->TAG_CLASS['flow'],
    'dl' => 'dt dd',
    'dt' => $this->TAG_CLASS['inline'],
    'dd' => $this->TAG_CLASS['flow'],
    
    /* inline */
    'span' => $this->TAG_CLASS['inline'],
    'b' => $this->TAG_CLASS['inline'],
    'i' => $this->TAG_CLASS['inline'],
    'em' => $this->TAG_CLASS['inline'],
    'strong' => $this->TAG_CLASS['inline'],
    'dfn' => $this->TAG_CLASS['inline'],
    'code' => $this->TAG_CLASS['inline'],
    'q' => $this->TAG_CLASS['inline'],
    'samp' => $this->TAG_CLASS['inline'],
    'kbd' => $this->TAG_CLASS['inline'],
    'var' => $this->TAG_CLASS['inline'],
    'cite' => $this->TAG_CLASS['inline'],
    'abbr' => $this->TAG_CLASS['inline'],
    'acronym' => $this->TAG_CLASS['inline'],
    'sub' => $this->TAG_CLASS['inline'],
    'sup' => $this->TAG_CLASS['inline'],
    'a' => $this->TAG_CLASS['inline']
  );
  
  // attribute white list
  $this->WHITE['ATT'] = array
  (
    'a' => 'href title', 
    'abbr' => 'title', 
    'acronym' => 'title', 
    'blockquote' => 'cite', 
    'dfn' => 'title', 
    'div' => 'style', 
    'q' => 'cite', 
    'span' => 'style',
  );
  
  // prepare parser
  $this->XmlParser = xml_parser_create();
	xml_set_object($this->XmlParser, &$this);
	xml_set_element_handler($this->XmlParser, 'tag_open', 'tag_close');
	xml_set_character_data_handler($this->XmlParser, 'cdata');
	xml_parser_set_option($this->XmlParser, XML_OPTION_CASE_FOLDING, false);
  
  // debug
  if ( $this->debug ) $this->print_d('debugging is active for oid ' . $this->oid);
  if ( $this->debug ) $this->print_d('constructor complete for class ' . __CLASS__);
}
// END constructor

// destructor
function __destruct()
{
  xml_parser_free($this->XmlParser);
  if ( $this->debug ) $this->print_d("destroying class {$this->class_name} (oid: {$this->oid})");
}
// END destructor



/* ** PUBLIC METHODS ** */
// method: dev
function validate($xhtml)
{
  // scrub input
  $xhtml = $this->_scrub_input($xhtml);

  // escape html entities
  $xhtml = utf8_encode($xhtml);
  $xhtml = preg_replace('/&([^\s]+;)/i', '&amp;$1', $xhtml);
  
  // wrap in container tag
  $xhtml = "<{$this->container_tag}>{$xhtml}</{$this->container_tag}>";
  
  // parse
  if ( !xml_parse($this->XmlParser, $xhtml) )
  {
    $error_string = xml_error_string(xml_get_error_code($this->XmlParser));
    $preamble = !empty($error_string) ? "there is a problem with your input: {$error_string}. " : '';
    $this->_parse_error($preamble);
  }
  else
  {
    if ( $this->debug ) $this->print_d('no parsing errors', '#6c6');
  }

  return;
}
// END method


// method: is valid
function is_valid()
{
  return count($this->ERROR) < 1;
}
// END method


// method: get prompt
function get_prompt($invalid_message='', $valid_message='')
{
  $prompt = '';       // return
  $error_list = '';
  
  if ( $this->debug ) $this->print_d('getting prompt');
  
  // valid prompt
  if ( $this->is_valid() )
  {
    return $valid_message;
  }
  
  // list errors
  foreach (  $this->ERROR as $_error )
  {
    $error_list .= "<div class=\"parse_error\">{$_error}</div>\n";
  }
  
  // build output
  $prompt = <<<PROMPT

<div class="html_validator_prompt">
<div class="invalid_message">$invalid_message</div>
$error_list
</div>
  
PROMPT;

  return $prompt;
}
// END method


// method: dev
function reset()
{
  if ( $this->debug ) $this->print_d('resetting parser');
  $this->ERROR = array();
  $this->_STACK = array();
  return;
}
// END method


// method: tag open
function tag_open($Parser, $tag, $ATT)
{
  if ( $this->debug ) $this->print_d(__FUNCTION__ . " called: tag = '$tag'");
  $previous = '';

  // look for container tag
  if ( $tag == $this->container_tag )
  {
    $this->_STACK[] = $this->container_tag;
    return;
  }
  
  // assign previous
  $previous = $this->_STACK[count($this->_STACK)-1];

  // skip test when previous tag disallowed
  if ( !in_array($previous, array_keys($this->TAG_TREE)) )
  {
    $this->_STACK[] = $tag;
    return;
  }

  // is tag allowed?
  if ( !in_array($tag, array_keys($this->TAG_TREE)) )
  {
    $this->_parse_error("tag &lt;$tag&gt; not allowed");
    $this->_STACK[] = $tag;
    return;
  }
  
  // is tag allowed in this context
  if ( !in_array($tag, explode(' ', $this->TAG_TREE[$previous])) )
  {
    if ( $previous == $this->container_tag ) 
      $this->_parse_error("tag &lt;{$tag}&gt; must be inside another tag");
    else 
      $this->_parse_error("tag &lt;{$tag}&gt; is not allowed within tag &lt;{$previous}&gt;");
  }

  // check tag attributes
  foreach ( $ATT as $_att => $_value )
  {
    // is attribute in our whitelist?
    if ( !isset($this->WHITE['ATT'][$tag]) || !in_array($_att, explode(' ', $this->WHITE['ATT'][$tag])) )
      $this->_parse_error("tag &lt;$tag&gt; may not have attribute $_att");
      
    // exception: javascript in href attribute
    if ( $_att == 'href' && preg_match('/^javascript/i', trim($_value)) )
      $this->_parse_error("href attributes may not contain the javascript: protocol");
      
    // exception: data in href attribute
    if ( $_att == 'href' && preg_match('/^data/i', trim($_value)) ) 
      $this->_parse_error("href attributes may not contain the data: protocol");

    // exception: javascript in blockquote citations (for use with blockquotes.js)
    if ( $_att == 'cite' && preg_match('/^javascript/i', trim($_value)) )
      $this->_parse_error("cite attributes may not contain the javascript: protocol"); 

    // exception: data in blockquote cites (for use with blockquotes.js)
    if ( $_att == 'cite' && preg_match('/^data/i', trim($_value)) )
      $this->_parse_error("cite attributes may not contain the data: protocol"); 
  }

  // set previous for checking nesting context rules
  $this->_STACK[] = $tag;

  return;
}
// END method


// method: cdata
function cdata($Parser, $cdata)
{
  if ( $this->debug ) $this->print_d(__FUNCTION__ . " called: cdata = '$cdata'");
  $previous = '';

  // check that 'previous' tag allows CDATA
  $previous = $this->_STACK[count($this->_STACK)-1];
  
  // skip where previous tag is disallowed
  if ( !in_array($previous, array_keys($this->TAG_TREE)) ) return;

  if ( trim($cdata) != '' )
  {
    if ( !in_array('#PCDATA', explode(' ', $this->TAG_TREE[$previous])) ) 
    {
      $this->_parse_error("tag &lt;{$previous}&gt; may not contain raw character data"); 
    }
  }

  return;
}
// END method


// method: tag close
function tag_close($Parser, $tag) 
{
  if ( $this->debug ) $this->print_d(__FUNCTION__ . " called: tag = '$tag'");
  array_pop($this->_STACK);
  return;
}
// END method


// method: print_d
function print_d($message, $color='#c33')
{
  $out = "<div style='line-height:1.5em; font-family:monospace; color:$color;'>$message</div>";
  echo $out;
  return;
}
// END method

// method: print_r
function print_r($Mixed)
{
  $return = htmlspecialchars(print_r($Mixed, 1));
  $return = "<pre>$return</pre>";
  return $return;
}
// END method

// method: dump
function dump()
{
  echo $this->print_r($this);
  return;
}
// END method



/* ** PRIVATE METHODS ** */
// method: trigger parse error
function _parse_error($message)
{
  if ( $this->debug ) trigger_error("parse error: $message", E_USER_NOTICE);
  $this->ERROR[] = $message;  
  return;
}
// END method


// method: scrub input
function _scrub_input($input)
{
  if ( $this->debug ) $this->print_d('scrubbing input (char length: '. strlen($input) .')');
  
  // scrub open comments
  $input = str_replace('<!--', '', $input);

  // scrub CDATA
  $input = str_replace('<![CDATA[', '', $input);

  // scrub script tokens
  $input = str_replace('<?', '', $input);
  $input = preg_replace('/<script/i', '', $input);

  return $input;
}
// END method



// method: _set_session_data
function _set_session_data()
{
  // initialize session
  if ( !session_id() ) session_start(); 
  $_SESSION[$this->oid] = array();
  
  // add session data here
  
  return;
}
// END method

// method: get session data
function _get_session_data()
{
  // initialize session
  if ( !$this->_has_session_data() ) return; 
    
  // retrieve session variables
  // $this->var = $_SESSION[$this->oid]['var'];
  
  return;
}
// END method

// method: has session data
function _has_session_data()
{
  // initialize session
  if ( !session_id() ) session_start(); 
    
  // retrieve session variables
  if ( empty($_SESSION[$this->oid]) ) return 0;
  else return 1;
}
// END method

function _set_filename() { $this->_filename = basename(__FILE__); }
function _set_dirpath() { $this->_dirpath = dirname(__FILE__) . $this->DS; }

} // end class
/*____________________________________________________________________________*/

?>
